Skip to content

fix: stop reporting the CLI's own timeout as BACKEND_UNAVAILABLE - #18

Open
StefanoGuerrini wants to merge 1 commit into
mainfrom
claude/cli-backend-timeout-masking-2zq2fb
Open

fix: stop reporting the CLI's own timeout as BACKEND_UNAVAILABLE#18
StefanoGuerrini wants to merge 1 commit into
mainfrom
claude/cli-backend-timeout-masking-2zq2fb

Conversation

@StefanoGuerrini

@StefanoGuerrini StefanoGuerrini commented Sep 2, 2026

Copy link
Copy Markdown

The problem

zenrows fetch aborted after 90s, the same value as the API's own request budget. Any
request that used the full budget was a race between our client abort and the API's real
error response, and the abort usually won. Every such failure surfaced as:

{
  "code": "BACKEND_UNAVAILABLE",
  "message": "Could not reach the Zenrows API.",
  "likely_cause": "Network error or timeout: This operation was aborted"
}

Both claims were wrong. The API had been reached, and it was about to answer with a
specific, actionable error. The user was sent to check connectivity instead of reading
the answer that already existed.

The change

Two independent defects, both fixed:

1. The client timeout equalled the server budget. The default is now 120_000 ms,
deliberately above the API's 90s ceiling, so the API always gets to answer for itself.
zenrows fetch gains --timeout <ms> to raise it further. Both values are named
constants in src/core/http.ts (DEFAULT_TIMEOUT_MS, SERVER_BUDGET_MS), so the
relationship between them is stated in one place instead of implied by two literals.

2. Any thrown error mapped to BACKEND_UNAVAILABLE. A client-side give-up is now
REQUEST_TIMEOUT. It carries the elapsed time, so the 90s boundary is visible, and it
points at --timeout or at dropping --wait-for instead of at the network.
BACKEND_UNAVAILABLE is reserved for genuine transport failures, and now also reports
elapsed time.

The same case after the change:

{
  "ok": false,
  "error": {
    "code": "REQUEST_TIMEOUT",
    "message": "The CLI stopped waiting after 120s. The Zenrows API did not respond in time.",
    "likely_cause": "The request was aborted client-side after 120s. The API was reached, so this is not a connectivity problem. The request also passed the API's own 90s budget, so the target is very likely rendering slowly or a wait condition never matched.",
    "next_action": "Retry with a longer client timeout (`--timeout 180000`). If the target needs a long render, drop `--wait-for` so the request finishes inside the API's budget and the API can return its own error instead.",
    "suggested_commands": [
      "zenrows fetch https://example.com/slow-page --timeout 180000"
    ]
  }
}

The trace-debug skill's failure-to-action map gains both codes, so an agent reading a
trace is steered the same way a human is.

Verification

npm run typecheck and npm test are clean (192 tests, up from 183). New coverage in
tests/fetch-timeout.test.ts:

  • the default client timeout is above the API's own request budget
  • --timeout accepts milliseconds and defaults when absent
  • --timeout rejects a non-numeric or non-positive value instead of silently ignoring it
  • runFetch threads --timeout through to the HTTP client
  • a client-side timeout is REQUEST_TIMEOUT, never BACKEND_UNAVAILABLE
  • a genuine transport failure is still BACKEND_UNAVAILABLE, with the elapsed time
  • REQUEST_TIMEOUT names the elapsed time and how to raise the timeout

The abort test drives the real code path: a fetch stub that never answers and rejects
only when the caller's own AbortController fires, which is what undici does in the
reported case. Each new assertion was confirmed non-vacuous by mutating the fix and
watching the specific test fail (dropping the timedOut branch fails 3, setting the
default back to 90s fails 1).

Scope notes

  • No --timeout on zenrows extract. extract goes through the same runFetch, so
    it picks up the 120s default and the REQUEST_TIMEOUT mapping automatically. The
    plumbing (FetchOptions.timeoutMs) is in place if the flag is wanted later.
  • Sibling HTTP clients are untouched. src/core/browser-api.ts,
    src/core/batch-api.ts, src/core/usage.ts and src/core/agent-account.ts map their
    own abort to BACKEND_UNAVAILABLE in the same way. This PR stays on
    src/core/http.ts, and requestTimeout() is exported so it can be reused verbatim.
  • Timeout detection uses our own timer flag rather than err.name === "AbortError",
    so an abort originating elsewhere cannot be misreported and there is no cross-runtime
    assumption about the error's name.

`zenrows fetch` aborted at 90s — exactly the gateway's own request
budget — so any request that used the full budget was a race between our
abort and the API's real error envelope, and the abort usually won. Every
such failure surfaced as:

    "code": "BACKEND_UNAVAILABLE",
    "message": "Could not reach the Zenrows API.",
    "likely_cause": "Network error or timeout: This operation was aborted"

Both claims were false. The API had been reached and was about to answer
with a specific, actionable error, so the operator was sent to check
connectivity instead of reading the answer that already existed.

Two independent defects, both fixed here:

- The client timeout equalled the server budget. The default is now
  120s, deliberately above the gateway's 90s ceiling, so the API always
  gets to answer for itself. `zenrows fetch` gains `--timeout <ms>`
  (milliseconds, mirroring `batch wait --timeout`) to raise it further;
  a non-numeric or non-positive value is rejected as INVALID_USAGE
  rather than silently falling back to the default.
- Every thrown error mapped to BACKEND_UNAVAILABLE. A client-side
  give-up is now REQUEST_TIMEOUT, carrying the elapsed time so the 90s
  boundary is visible, and pointing at `--timeout` / dropping
  `--wait-for` instead of at the network. BACKEND_UNAVAILABLE is left
  for genuine transport failures only, and now also reports elapsed
  time.

Our own timer flag, not `err.name === "AbortError"`, is what separates
the two: it cannot be confused with an abort from anywhere else.

The trace-debug skill's failure → action map gains both codes, so an
agent reading a trace is steered the same way.

Refs ACT-1605

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vy8dYdUhLJoS6EHw5jn9mg
@linear

linear Bot commented Sep 2, 2026

Copy link
Copy Markdown

ACT-1605

@StefanoGuerrini
StefanoGuerrini marked this pull request as ready for review September 2, 2026 13:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants